【Swift】Codableで動的なキーを持つJSONに対応する
概要
大阪オフィスの山田です。この前APIClientを実装する記事を書きましたが、その中で、キーが動的に変わるJSON
と遭遇しました。その際、Codable
を使うにはどう実装したら良いか調べたので、記事にします。
開発環境
- macOS: 10.15.4
- Xcode: 11.5
キーが動的に変わるJSONの例
以前の記事にも登場した、Gistに投稿するGitHub API
を例にあげます。
以下のbodyでPOSTします。
{ "public": true, "files": { "gist.txt": { "content": "Toukou!" } } }
この中でgist.txt
はGistに投稿するファイル名であり、登録したいファイル名によってキーの値が変わります。APIのドキュメントはこちら
モデルのプロパティを定義する
APIの仕様では、filesは複数指定できますが、今回は簡略化のため、1つのみ指定するようにします。
struct PostGist { let `public`: Bool let fileName: String let content: String }
public
は予約語なのでバッククォートで囲んでいます。
JSON形式に変換したいので、Encodable
を継承させます。
自由に生成できるCodingKeyを定義する
以下のようにEncodable
を継承します。そして、keyを外部から受け付けられるようにCodingKey
を継承したCustomCodingKey
を定義しています。CustomCodingKey
の中で動的に変化しないプロパティ(publicやfilesなど)は利便性のためにstaticで宣言しています。
extension PostGist: Encodable { private struct CustomCodingKey: CodingKey { var stringValue: String init?(stringValue: String) { self.stringValue = stringValue } var intValue: Int? init?(intValue: Int) { return nil } static let `public` = CustomCodingKey(stringValue: "public")! static let files = CustomCodingKey(stringValue: "files")! static let content = CustomCodingKey(stringValue: "content")! } }
intValue
も設定できるようですが、int値をキーにするパターンが思いつかなかったので今回はnilを返却するようにしています。
次にencode
メソッドを実装します。
extension PostGist: Encodable { // ...省略... func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CustomCodingKey.self) try container.encode(`public`, forKey: .public) var filesContainer = container.nestedContainer(keyedBy: CustomCodingKey.self, forKey: .files) let fileNameKey = CustomCodingKey(stringValue: fileName)! var fileContainer = filesContainer.nestedContainer(keyedBy: CustomCodingKey.self, forKey: fileNameKey) try fileContainer.encode(content, forKey: .content) } }
基本的にはencode
メソッドを実装するのとかわり映えしませんが、JSONで指定するファイル名の部分はfileName
プロパティを使ってCustomCodingKey
を生成し、それを使ってnestedContainer
メソッドを使ってcontainer(KeyedEncodingContainer)
を取得しています。そのcontainerの中にcontent
が入るようにしています。
実際にエンコードして確認する
実際にエンコードしてみて動作を確認します。
let gist = PostGist(public: false, fileName: "gist.txt", content: "Toukou!") let data = try! JSONEncoder().encode(gist) print(String(data: data, encoding: String.Encoding.utf8)!)
すると以下のようにコンソールに出力されます。
{"public":false,"files":{"gist.txt":{"content":"Toukou!"}}}
読みやすく整えると以下のようになります。
{ "public": false, "files": { "gist.txt": { "content": "Toukou!" } } }
Decodableを継承してデコードできるようにする
Decodable & Encodable
がCodable
なので、Codable
を継承して、init(from decoder: Decoder) throws
メソッドを実装します。
extension PostGist: Codable { // ...省略... init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CustomCodingKey.self) `public` = try container.decode(Bool.self, forKey: .public) let filesContainer = try container.nestedContainer(keyedBy: CustomCodingKey.self, forKey: .files) fileName = filesContainer.allKeys.first!.stringValue let fileContainer = try filesContainer.nestedContainer(keyedBy: CustomCodingKey.self, forKey: CustomCodingKey(stringValue: fileName)!) content = try fileContainer.decode(String.self, forKey: .content) } // ...省略... }
JSONのfiles
の中にあるキーの値をfileName
プロパティの値として使うようにしています。
複数ファイルが存在する場合に対応させる場合はfor key in fileContainer.allKeys
のように全てのキーを参照することで実現可能です。
実際にデコードして確認する
実際にデコードして動作を確認します。
let json = """ { "public": false, "files": { "gist.txt": { "content": "Toukou!" } } } """ let decoder = JSONDecoder() if let jsonData = json.data(using: .utf8) { do { let results = try decoder.decode(PostGist.self, from: jsonData) print(results) } catch { print(error) } }
コンソールに以下のように表示されて、インスタンスが生成されていることがわかります。
PostGist(public: false, fileName: "gist.txt", content: "Toukou!")
おわりに
毎年夏になると山の方まで、ヒグラシの鳴き声が聞きに行っています。カナカナ。